/**
 * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */
/**
 * @module heading/title
 */
import { Plugin } from 'ckeditor5/src/core';
import { first } from 'ckeditor5/src/utils';
import { DowncastWriter, enablePlaceholder, hidePlaceholder, needsPlaceholder, showPlaceholder } from 'ckeditor5/src/engine';
// A list of element names that should be treated by the Title plugin as title-like.
// This means that an element of a type from this list will be changed to a title element
// when it is the first element in the root.
const titleLikeElements = new Set(['paragraph', 'heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6']);
/**
 * The Title plugin.
 *
 * It splits the document into `Title` and `Body` sections.
 */
export default class Title extends Plugin {
    constructor() {
        super(...arguments);
        /**
         * A reference to an empty paragraph in the body
         * created when there is no element in the body for the placeholder purposes.
         */
        this._bodyPlaceholder = new Map();
    }
    /**
     * @inheritDoc
     */
    static get pluginName() {
        return 'Title';
    }
    /**
     * @inheritDoc
     */
    static get requires() {
        return ['Paragraph'];
    }
    /**
     * @inheritDoc
     */
    init() {
        const editor = this.editor;
        const model = editor.model;
        // To use the schema for disabling some features when the selection is inside the title element
        // it is needed to create the following structure:
        //
        // <title>
        //     <title-content>The title text</title-content>
        // </title>
        //
        // See: https://github.com/ckeditor/ckeditor5/issues/2005.
        model.schema.register('title', { isBlock: true, allowIn: '$root' });
        model.schema.register('title-content', { isBlock: true, allowIn: 'title', allowAttributes: ['alignment'] });
        model.schema.extend('$text', { allowIn: 'title-content' });
        // Disallow all attributes in `title-content`.
        model.schema.addAttributeCheck(context => {
            if (context.endsWith('title-content $text')) {
                return false;
            }
        });
        // Because `title` is represented by two elements in the model
        // but only one in the view, it is needed to adjust Mapper.
        editor.editing.mapper.on('modelToViewPosition', mapModelPositionToView(editor.editing.view));
        editor.data.mapper.on('modelToViewPosition', mapModelPositionToView(editor.editing.view));
        // Conversion.
        editor.conversion.for('downcast').elementToElement({ model: 'title-content', view: 'h1' });
        editor.conversion.for('downcast').add(dispatcher => dispatcher.on('insert:title', (evt, data, conversionApi) => {
            conversionApi.consumable.consume(data.item, evt.name);
        }));
        // Custom converter is used for data v -> m conversion to avoid calling post-fixer when setting data.
        // See https://github.com/ckeditor/ckeditor5/issues/2036.
        editor.data.upcastDispatcher.on('element:h1', dataViewModelH1Insertion, { priority: 'high' });
        editor.data.upcastDispatcher.on('element:h2', dataViewModelH1Insertion, { priority: 'high' });
        editor.data.upcastDispatcher.on('element:h3', dataViewModelH1Insertion, { priority: 'high' });
        // Take care about correct `title` element structure.
        model.document.registerPostFixer(writer => this._fixTitleContent(writer));
        // Create and take care of correct position of a `title` element.
        model.document.registerPostFixer(writer => this._fixTitleElement(writer));
        // Create element for `Body` placeholder if it is missing.
        model.document.registerPostFixer(writer => this._fixBodyElement(writer));
        // Prevent from adding extra at the end of the document.
        model.document.registerPostFixer(writer => this._fixExtraParagraph(writer));
        // Attach `Title` and `Body` placeholders to the empty title and/or content.
        this._attachPlaceholders();
        // Attach Tab handling.
        this._attachTabPressHandling();
    }
    /**
     * Returns the title of the document. Note that because this plugin does not allow any formatting inside
     * the title element, the output of this method will be a plain text, with no HTML tags.
     *
     * It is not recommended to use this method together with features that insert markers to the
     * data output, like comments or track changes features. If such markers start in the title and end in the
     * body, the result of this method might be incorrect.
     *
     * @param options Additional configuration passed to the conversion process.
     * See {@link module:engine/controller/datacontroller~DataController#get `DataController#get`}.
     * @returns The title of the document.
     */
    getTitle(options = {}) {
        const rootName = options.rootName ? options.rootName : undefined;
        const titleElement = this._getTitleElement(rootName);
        const titleContentElement = titleElement.getChild(0);
        return this.editor.data.stringify(titleContentElement, options);
    }
    /**
     * Returns the body of the document.
     *
     * Note that it is not recommended to use this method together with features that insert markers to the
     * data output, like comments or track changes features. If such markers start in the title and end in the
     * body, the result of this method might be incorrect.
     *
     * @param options Additional configuration passed to the conversion process.
     * See {@link module:engine/controller/datacontroller~DataController#get `DataController#get`}.
     * @returns The body of the document.
     */
    getBody(options = {}) {
        const editor = this.editor;
        const data = editor.data;
        const model = editor.model;
        const rootName = options.rootName ? options.rootName : undefined;
        const root = editor.model.document.getRoot(rootName);
        const view = editor.editing.view;
        const viewWriter = new DowncastWriter(view.document);
        const rootRange = model.createRangeIn(root);
        const viewDocumentFragment = viewWriter.createDocumentFragment();
        // Find all markers that intersects with body.
        const bodyStartPosition = model.createPositionAfter(root.getChild(0));
        const bodyRange = model.createRange(bodyStartPosition, model.createPositionAt(root, 'end'));
        const markers = new Map();
        for (const marker of model.markers) {
            const intersection = bodyRange.getIntersection(marker.getRange());
            if (intersection) {
                markers.set(marker.name, intersection);
            }
        }
        // Convert the entire root to view.
        data.mapper.clearBindings();
        data.mapper.bindElements(root, viewDocumentFragment);
        data.downcastDispatcher.convert(rootRange, markers, viewWriter, options);
        // Remove title element from view.
        viewWriter.remove(viewWriter.createRangeOn(viewDocumentFragment.getChild(0)));
        // view -> data
        return editor.data.processor.toData(viewDocumentFragment);
    }
    /**
     * Returns the `title` element when it is in the document. Returns `undefined` otherwise.
     */
    _getTitleElement(rootName) {
        const root = this.editor.model.document.getRoot(rootName);
        for (const child of root.getChildren()) {
            if (isTitle(child)) {
                return child;
            }
        }
    }
    /**
     * Model post-fixer callback that ensures that `title` has only one `title-content` child.
     * All additional children should be moved after the `title` element and renamed to a paragraph.
     */
    _fixTitleContent(writer) {
        let changed = false;
        for (const rootName of this.editor.model.document.getRootNames()) {
            const title = this._getTitleElement(rootName);
            // If there is no title in the content it will be created by `_fixTitleElement` post-fixer.
            // If the title has just one element, then it is correct. No fixing.
            if (!title || title.maxOffset === 1) {
                continue;
            }
            const titleChildren = Array.from(title.getChildren());
            // Skip first child because it is an allowed element.
            titleChildren.shift();
            for (const titleChild of titleChildren) {
                writer.move(writer.createRangeOn(titleChild), title, 'after');
                writer.rename(titleChild, 'paragraph');
            }
            changed = true;
        }
        return changed;
    }
    /**
     * Model post-fixer callback that creates a title element when it is missing,
     * takes care of the correct position of it and removes additional title elements.
     */
    _fixTitleElement(writer) {
        let changed = false;
        const model = this.editor.model;
        for (const modelRoot of this.editor.model.document.getRoots()) {
            const titleElements = Array.from(modelRoot.getChildren()).filter(isTitle);
            const firstTitleElement = titleElements[0];
            const firstRootChild = modelRoot.getChild(0);
            // When title element is at the beginning of the document then try to fix additional title elements (if there are any).
            if (firstRootChild.is('element', 'title')) {
                if (titleElements.length > 1) {
                    fixAdditionalTitleElements(titleElements, writer, model);
                    changed = true;
                }
                continue;
            }
            // When there is no title in the document and first element in the document cannot be changed
            // to the title then create an empty title element at the beginning of the document.
            if (!firstTitleElement && !titleLikeElements.has(firstRootChild.name)) {
                const title = writer.createElement('title');
                writer.insert(title, modelRoot);
                writer.insertElement('title-content', title);
                changed = true;
                continue;
            }
            if (titleLikeElements.has(firstRootChild.name)) {
                // Change the first element in the document to the title if it can be changed (is title-like).
                changeElementToTitle(firstRootChild, writer, model);
            }
            else {
                // Otherwise, move the first occurrence of the title element to the beginning of the document.
                writer.move(writer.createRangeOn(firstTitleElement), modelRoot, 0);
            }
            fixAdditionalTitleElements(titleElements, writer, model);
            changed = true;
        }
        return changed;
    }
    /**
     * Model post-fixer callback that adds an empty paragraph at the end of the document
     * when it is needed for the placeholder purposes.
     */
    _fixBodyElement(writer) {
        let changed = false;
        for (const rootName of this.editor.model.document.getRootNames()) {
            const modelRoot = this.editor.model.document.getRoot(rootName);
            if (modelRoot.childCount < 2) {
                const placeholder = writer.createElement('paragraph');
                writer.insert(placeholder, modelRoot, 1);
                this._bodyPlaceholder.set(rootName, placeholder);
                changed = true;
            }
        }
        return changed;
    }
    /**
     * Model post-fixer callback that removes a paragraph from the end of the document
     * if it was created for the placeholder purposes and is not needed anymore.
     */
    _fixExtraParagraph(writer) {
        let changed = false;
        for (const rootName of this.editor.model.document.getRootNames()) {
            const root = this.editor.model.document.getRoot(rootName);
            const placeholder = this._bodyPlaceholder.get(rootName);
            if (shouldRemoveLastParagraph(placeholder, root)) {
                this._bodyPlaceholder.delete(rootName);
                writer.remove(placeholder);
                changed = true;
            }
        }
        return changed;
    }
    /**
     * Attaches the `Title` and `Body` placeholders to the title and/or content.
     */
    _attachPlaceholders() {
        const editor = this.editor;
        const t = editor.t;
        const view = editor.editing.view;
        const sourceElement = editor.sourceElement;
        const titlePlaceholder = editor.config.get('title.placeholder') || t('Type your title');
        const bodyPlaceholder = editor.config.get('placeholder') ||
            sourceElement && sourceElement.tagName.toLowerCase() === 'textarea' && sourceElement.getAttribute('placeholder') ||
            t('Type or paste your content here.');
        // Attach placeholder to the view title element.
        editor.editing.downcastDispatcher.on('insert:title-content', (evt, data, conversionApi) => {
            const element = conversionApi.mapper.toViewElement(data.item);
            element.placeholder = titlePlaceholder;
            enablePlaceholder({
                view,
                element,
                keepOnFocus: true
            });
        });
        // Attach placeholder to first element after a title element and remove it if it's not needed anymore.
        // First element after title can change, so we need to observe all changes keep placeholder in sync.
        const bodyViewElements = new Map();
        // This post-fixer runs after the model post-fixer, so we can assume that the second child in view root will always exist.
        view.document.registerPostFixer(writer => {
            let hasChanged = false;
            for (const viewRoot of view.document.roots) {
                // `viewRoot` can be empty despite the model post-fixers if the model root was detached.
                if (viewRoot.isEmpty) {
                    continue;
                }
                // If `viewRoot` is not empty, then we can expect at least two elements in it.
                const body = viewRoot.getChild(1);
                const oldBody = bodyViewElements.get(viewRoot.rootName);
                // If body element has changed we need to disable placeholder on the previous element and enable on the new one.
                if (body !== oldBody) {
                    if (oldBody) {
                        hidePlaceholder(writer, oldBody);
                        writer.removeAttribute('data-placeholder', oldBody);
                    }
                    writer.setAttribute('data-placeholder', bodyPlaceholder, body);
                    bodyViewElements.set(viewRoot.rootName, body);
                    hasChanged = true;
                }
                // Then we need to display placeholder if it is needed.
                // See: https://github.com/ckeditor/ckeditor5/issues/8689.
                if (needsPlaceholder(body, true) && viewRoot.childCount === 2 && body.name === 'p') {
                    hasChanged = showPlaceholder(writer, body) ? true : hasChanged;
                }
                else {
                    // Or hide if it is not needed.
                    hasChanged = hidePlaceholder(writer, body) ? true : hasChanged;
                }
            }
            return hasChanged;
        });
    }
    /**
     * Creates navigation between the title and body sections using <kbd>Tab</kbd> and <kbd>Shift</kbd>+<kbd>Tab</kbd> keys.
     */
    _attachTabPressHandling() {
        const editor = this.editor;
        const model = editor.model;
        // Pressing <kbd>Tab</kbd> inside the title should move the caret to the body.
        editor.keystrokes.set('TAB', (data, cancel) => {
            model.change(writer => {
                const selection = model.document.selection;
                const selectedElements = Array.from(selection.getSelectedBlocks());
                if (selectedElements.length === 1 && selectedElements[0].is('element', 'title-content')) {
                    const root = selection.getFirstPosition().root;
                    const firstBodyElement = root.getChild(1);
                    writer.setSelection(firstBodyElement, 0);
                    cancel();
                }
            });
        });
        // Pressing <kbd>Shift</kbd>+<kbd>Tab</kbd> at the beginning of the body should move the caret to the title.
        editor.keystrokes.set('SHIFT + TAB', (data, cancel) => {
            model.change(writer => {
                const selection = model.document.selection;
                if (!selection.isCollapsed) {
                    return;
                }
                const selectedElement = first(selection.getSelectedBlocks());
                const selectionPosition = selection.getFirstPosition();
                const root = editor.model.document.getRoot(selectionPosition.root.rootName);
                const title = root.getChild(0);
                const body = root.getChild(1);
                if (selectedElement === body && selectionPosition.isAtStart) {
                    writer.setSelection(title.getChild(0), 0);
                    cancel();
                }
            });
        });
    }
}
/**
 * A view-to-model converter for the h1 that appears at the beginning of the document (a title element).
 *
 * @see module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
 * @param evt An object containing information about the fired event.
 * @param data An object containing conversion input, a placeholder for conversion output and possibly other values.
 * @param conversionApi Conversion interface to be used by the callback.
 */
function dataViewModelH1Insertion(evt, data, conversionApi) {
    const modelCursor = data.modelCursor;
    const viewItem = data.viewItem;
    if (!modelCursor.isAtStart || !modelCursor.parent.is('element', '$root')) {
        return;
    }
    if (!conversionApi.consumable.consume(viewItem, { name: true })) {
        return;
    }
    const modelWriter = conversionApi.writer;
    const title = modelWriter.createElement('title');
    const titleContent = modelWriter.createElement('title-content');
    modelWriter.append(titleContent, title);
    modelWriter.insert(title, modelCursor);
    conversionApi.convertChildren(viewItem, titleContent);
    conversionApi.updateConversionResult(title, data);
}
/**
 * Maps position from the beginning of the model `title` element to the beginning of the view `h1` element.
 *
 * ```html
 * <title>^<title-content>Foo</title-content></title> -> <h1>^Foo</h1>
 * ```
 */
function mapModelPositionToView(editingView) {
    return (evt, data) => {
        const positionParent = data.modelPosition.parent;
        if (!positionParent.is('element', 'title')) {
            return;
        }
        const modelTitleElement = positionParent.parent;
        const viewElement = data.mapper.toViewElement(modelTitleElement);
        data.viewPosition = editingView.createPositionAt(viewElement, 0);
        evt.stop();
    };
}
/**
 * @returns Returns true when given element is a title. Returns false otherwise.
 */
function isTitle(element) {
    return element.is('element', 'title');
}
/**
 * Changes the given element to the title element.
 */
function changeElementToTitle(element, writer, model) {
    const title = writer.createElement('title');
    writer.insert(title, element, 'before');
    writer.insert(element, title, 0);
    writer.rename(element, 'title-content');
    model.schema.removeDisallowedAttributes([element], writer);
}
/**
 * Loops over the list of title elements and fixes additional ones.
 *
 * @returns Returns true when there was any change. Returns false otherwise.
 */
function fixAdditionalTitleElements(titleElements, writer, model) {
    let hasChanged = false;
    for (const title of titleElements) {
        if (title.index !== 0) {
            fixTitleElement(title, writer, model);
            hasChanged = true;
        }
    }
    return hasChanged;
}
/**
 * Changes given title element to a paragraph or removes it when it is empty.
 */
function fixTitleElement(title, writer, model) {
    const child = title.getChild(0);
    // Empty title should be removed.
    // It is created as a result of pasting to the title element.
    if (child.isEmpty) {
        writer.remove(title);
        return;
    }
    writer.move(writer.createRangeOn(child), title, 'before');
    writer.rename(child, 'paragraph');
    writer.remove(title);
    model.schema.removeDisallowedAttributes([child], writer);
}
/**
 * Returns true when the last paragraph in the document was created only for the placeholder
 * purpose and it's not needed anymore. Returns false otherwise.
 */
function shouldRemoveLastParagraph(placeholder, root) {
    if (!placeholder || !placeholder.is('element', 'paragraph') || placeholder.childCount) {
        return false;
    }
    if (root.childCount <= 2 || root.getChild(root.childCount - 1) !== placeholder) {
        return false;
    }
    return true;
}
